"use client"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { Prisma } from "@prisma/client"; import Link from "next/link"; import React, { useCallback, useState, useEffect } from "react"; import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder"; import type { ImmutableTree, BuilderProps, Config } from "react-awesome-query-builder"; import type { JsonTree } from "react-awesome-query-builder"; import type { UseFormReturn } from "react-hook-form"; import { Toaster } from "sonner"; import type { z } from "zod"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils"; import { SchedulingType } from "@calcom/prisma/client"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import classNames from "@calcom/ui/classNames"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { FormCard } from "@calcom/ui/components/card"; import { SelectWithValidation as Select, TextArea } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; import { SelectField } from "@calcom/ui/components/form"; import { Switch } from "@calcom/ui/components/form"; import type { IconName } from "@calcom/ui/components/icon"; import { Icon } from "@calcom/ui/components/icon"; import { routingFormAppComponents } from "../../appComponents"; import DynamicAppComponent from "../../components/DynamicAppComponent"; import SingleForm from "../../components/SingleForm"; import { EmptyState } from "../../components/_components/EmptyState"; import { RoutingSkeleton } from "../../components/_components/RoutingSkeleton"; import type { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/getServerSidePropsSingleForm"; import { withRaqbSettingsAndWidgets, ConfigFor, } from "../../components/react-awesome-query-builder/config/uiConfig"; import { RoutingPages } from "../../lib/RoutingPages"; import { createFallbackRoute } from "../../lib/createFallbackRoute"; import getEventTypeAppMetadata from "../../lib/getEventTypeAppMetadata"; import { getQueryBuilderConfigForFormFields, getQueryBuilderConfigForAttributes, type FormFieldsQueryBuilderConfigWithRaqbFields, type AttributesQueryBuilderConfigWithRaqbFields, isDynamicOperandField, } from "../../lib/getQueryBuilderConfig"; import isRouter from "../../lib/isRouter"; import type { RoutingFormWithResponseCount } from "../../types/types"; import type { GlobalRoute, LocalRoute, SerializableRoute, Attribute, EditFormRoute, AttributeRoutingConfig, } from "../../types/types"; import type { zodRoutes } from "../../zod"; import { RouteActionType } from "../../zod"; type EventTypesByGroup = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]; type Form = inferSSRProps["form"]; type SetRoute = (id: string, route: Partial) => void; type AttributesQueryValue = NonNullable; type FormFieldsQueryValue = LocalRoute["queryValue"]; /** * We need eventTypeId in every redirect url action now for Rerouting to work smoothly. * This hook ensures that it is there as soon as someone lands on a Routing Form and next save would automatically update it for them. */ function useEnsureEventTypeIdInRedirectUrlAction({ route, eventOptions, setRoute, }: { route: EditFormRoute; eventOptions: { label: string; value: string; eventTypeId: number }[]; setRoute: SetRoute; }) { useEffect(() => { if (isRouter(route)) { return; } if ( route.action.type !== RouteActionType.EventTypeRedirectUrl || // Must not be set already. Could be zero as well for custom route.action.eventTypeId !== undefined ) { return; } const matchingOption = eventOptions.find((eventOption) => eventOption.value === route.action.value); if (!matchingOption) { return; } setRoute(route.id, { action: { ...route.action, eventTypeId: matchingOption.eventTypeId }, }); }, [eventOptions, setRoute, route.id, (route as unknown as any).action?.value]); } const hasRules = (route: EditFormRoute) => { if (isRouter(route)) return false; route.queryValue.children1 && Object.keys(route.queryValue.children1).length; }; function getEmptyQueryValue() { return buildEmptyQueryValue(); } const getEmptyRoute = (): Exclude => { const uuid = QbUtils.uuid(); const formFieldsQueryValue = getEmptyQueryValue() as FormFieldsQueryValue; const attributesQueryValue = getEmptyQueryValue() as AttributesQueryValue; const fallbackAttributesQueryValue = getEmptyQueryValue() as AttributesQueryValue; return { id: uuid, action: { type: RouteActionType.EventTypeRedirectUrl, value: "", }, // It is actually formFieldsQueryValue queryValue: formFieldsQueryValue, attributesQueryValue: attributesQueryValue, fallbackAttributesQueryValue: fallbackAttributesQueryValue, }; }; const buildEventsData = ({ eventTypesByGroup, form, route, }: { eventTypesByGroup: EventTypesByGroup | undefined; form: Form; route: EditFormRoute; }) => { const eventOptions: { label: string; value: string; eventTypeId: number; eventTypeAppMetadata?: Record; isRRWeightsEnabled: boolean; }[] = []; const eventTypesMap = new Map< number, { schedulingType: SchedulingType | null; eventTypeAppMetadata?: Record; } >(); eventTypesByGroup?.eventTypeGroups.forEach((group) => { const eventTypeValidInContext = areTheySiblingEntities({ entity1: { teamId: group.teamId ?? null, // group doesn't have userId. The query ensures that it belongs to the user only, if teamId isn't set. So, I am manually setting it to the form userId userId: form.userId, }, entity2: { teamId: form.teamId ?? null, userId: form.userId, }, }); group.eventTypes.forEach((eventType) => { if (eventType.teamId && eventType.schedulingType === SchedulingType.MANAGED) { return; } const uniqueSlug = `${group.profile.slug}/${eventType.slug}`; const isRouteAlreadyInUse = isRouter(route) ? false : uniqueSlug === route.action.value; // If Event is already in use, we let it be so as to not break the existing setup if (!isRouteAlreadyInUse && !eventTypeValidInContext) { return; } // Pass app data that works with routing forms const eventTypeAppMetadata = getEventTypeAppMetadata(eventType.metadata as Prisma.JsonValue); eventTypesMap.set(eventType.id, { eventTypeAppMetadata, schedulingType: eventType.schedulingType, }); eventOptions.push({ label: uniqueSlug, value: uniqueSlug, eventTypeId: eventType.id, eventTypeAppMetadata, isRRWeightsEnabled: eventType.isRRWeightsEnabled, }); }); }); return { eventOptions, eventTypesMap }; }; const isValidAttributeIdForWeights = ({ attributeIdForWeights, jsonTree, }: { attributeIdForWeights: string; jsonTree: JsonTree; }) => { if (!attributeIdForWeights || !jsonTree.children1) { return false; } return Object.values(jsonTree.children1).some((rule) => { if (rule.type !== "rule" || rule?.properties?.field !== attributeIdForWeights) { return false; } const values = rule.properties.value.flat(); return values.length === 1 && values.some((value: string) => isDynamicOperandField(value)); }); }; const WeightedAttributesSelector = ({ attributes, route, eventTypeRedirectUrlSelectedOption, setRoute, }: { attributes?: Attribute[]; route: EditFormRoute; eventTypeRedirectUrlSelectedOption: { isRRWeightsEnabled: boolean } | undefined; setRoute: SetRoute; }) => { const [attributeIdForWeights, setAttributeIdForWeights] = useState( "attributeIdForWeights" in route ? route.attributeIdForWeights : undefined ); const { t } = useLocale(); if (isRouter(route)) { return null; } let attributesWithWeightsEnabled: Attribute[] = []; if (eventTypeRedirectUrlSelectedOption?.isRRWeightsEnabled) { const validatedQueryValue = route.attributesQueryBuilderState?.tree ? QbUtils.getTree(route.attributesQueryBuilderState.tree) : null; if ( validatedQueryValue && raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue) && validatedQueryValue.children1 ) { const attributeIds = Object.values(validatedQueryValue.children1).map((rule) => { if (rule.type === "rule" && rule?.properties?.field) { if ( rule.properties.value.flat().length == 1 && rule.properties.value.flat().some((value) => isDynamicOperandField(value)) ) { return rule.properties.field; } } }); attributesWithWeightsEnabled = attributes ? attributes.filter( (attribute) => attribute.isWeightsEnabled && attributeIds.find((attributeId) => attributeId === attribute.id) ) : []; } } const onChangeAttributeIdForWeights = ( route: EditFormRoute & { attributeIdForWeights?: string }, attributeIdForWeights?: string ) => { setRoute(route.id, { attributeIdForWeights, }); }; return attributesWithWeightsEnabled.length > 0 ? (
<>
{t("use_attribute_weights")} {t("if_enabled_ignore_event_type_weights")}
{ const attributeId = checked ? attributesWithWeightsEnabled[0].id : undefined; setAttributeIdForWeights(attributeId); onChangeAttributeIdForWeights(route, attributeId); }} />
{!!attributeIdForWeights ? ( { return { value: attribute.id, label: attribute.name }; })} value={{ value: attributeIdForWeights, label: attributesWithWeightsEnabled.find( (attribute) => attribute.id === attributeIdForWeights )?.name, }} onChange={(option) => { if (option) { setAttributeIdForWeights(option.value); onChangeAttributeIdForWeights(route, option.value); } }} /> ) : ( <> )}
) : null; }; const Route = ({ form, route, routes, setRoute, setAttributeRoutingConfig, formFieldsQueryBuilderConfig, attributesQueryBuilderConfig, setRoutes, moveUp, moveDown, appUrl, disabled = false, fieldIdentifiers, eventTypesByGroup, attributes, cardOptions, }: { form: Form; route: EditFormRoute; routes: EditFormRoute[]; setRoute: SetRoute; setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial) => void; formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; setRoutes: React.Dispatch>; fieldIdentifiers: string[]; moveUp?: { fn: () => void; check: () => boolean } | null; moveDown?: { fn: () => void; check: () => boolean } | null; appUrl: string; disabled?: boolean; eventTypesByGroup: EventTypesByGroup; attributes?: Attribute[]; cardOptions?: { collapsible?: boolean; leftIcon?: IconName; }; }) => { const { t } = useLocale(); const isTeamForm = form.teamId !== null; const index = routes.indexOf(route); const { eventOptions } = buildEventsData({ eventTypesByGroup, form, route }); const orgBranding = useOrgBranding(); const isOrganization = !!orgBranding; // /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG} const eventTypePrefix = eventOptions.length !== 0 ? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1) : ""; const [customEventTypeSlug, setCustomEventTypeSlug] = useState(""); useEffect(() => { const isCustom = !isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value); setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : ""); }, []); useEnsureEventTypeIdInRedirectUrlAction({ route, eventOptions, setRoute, }); const onChangeFormFieldsQuery = ( route: EditFormRoute, immutableTree: ImmutableTree, config: FormFieldsQueryBuilderConfigWithRaqbFields ) => { const jsonTree = QbUtils.getTree(immutableTree) as LocalRoute["queryValue"]; setRoute(route.id, { formFieldsQueryBuilderState: { tree: immutableTree, config: config }, queryValue: jsonTree, }); }; const setAttributeIdForWeights = (attributeIdForWeights: string | undefined) => { setRoute(route.id, { attributeIdForWeights, }); }; const onChangeTeamMembersQuery = ( route: EditFormRoute, immutableTree: ImmutableTree, config: AttributesQueryBuilderConfigWithRaqbFields ) => { const jsonTree = QbUtils.getTree(immutableTree); const attributeIdForWeights = isRouter(route) ? null : route.attributeIdForWeights; const _isValidAttributeIdForWeights = attributeIdForWeights && isValidAttributeIdForWeights({ attributeIdForWeights, jsonTree }); if (attributeIdForWeights && !_isValidAttributeIdForWeights) { setAttributeIdForWeights(undefined); } setRoute(route.id, { attributesQueryBuilderState: { tree: immutableTree, config: config }, attributesQueryValue: jsonTree as AttributesQueryValue, attributeIdForWeights: _isValidAttributeIdForWeights ? attributeIdForWeights : undefined, }); }; const onChangeFallbackTeamMembersQuery = ( route: EditFormRoute, immutableTree: ImmutableTree, config: AttributesQueryBuilderConfigWithRaqbFields ) => { const jsonTree = QbUtils.getTree(immutableTree); setRoute(route.id, { fallbackAttributesQueryBuilderState: { tree: immutableTree, config: config }, fallbackAttributesQueryValue: jsonTree as AttributesQueryValue, }); }; const renderBuilder = useCallback( (props: BuilderProps) => (
), [] ); if (isRouter(route)) { return (
routes.length !== 1, fn: () => { const newRoutes = routes.filter((r) => r.id !== route.id); setRoutes(newRoutes); }, }} isLabelEditable={false} label={route.name ?? `Route ${index + 1}`} className="mb-6">
{route.name}

Fields available in {route.name} will be added to this form.

); } const shouldShowFormFieldsQueryBuilder = (route.isFallback && hasRules(route)) || !route.isFallback; const eventTypeRedirectUrlOptions = eventOptions.length !== 0 ? [{ label: t("custom"), value: "custom", eventTypeId: 0, isRRWeightsEnabled: false }].concat( eventOptions ) : []; const eventTypeRedirectUrlSelectedOption = eventOptions.length !== 0 && route.action.value !== "" ? eventOptions.find( (eventOption) => eventOption.value === route.action.value && !customEventTypeSlug.length ) || { label: t("custom"), value: "custom", eventTypeId: 0, isRRWeightsEnabled: false, } : undefined; const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
Conditions
{ onChangeFormFieldsQuery( route, immutableTree, formFieldsQueryBuilderConfig as unknown as FormFieldsQueryBuilderConfigWithRaqbFields ); }} renderBuilder={renderBuilder} />
) : null; const attributesQueryBuilderConfigWithRaqbSettingsAndWidgets = attributesQueryBuilderConfig ? withRaqbSettingsAndWidgets({ config: attributesQueryBuilderConfig, configFor: ConfigFor.Attributes, }) : null; const attributesQueryBuilder = // team member attributes are only available for organization teams route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm && isOrganization ? (
{/* TODO: */} {eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata && "salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
) : null}
And connect with specific team members
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && ( { onChangeTeamMembersQuery( route, immutableTree, attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields ); }} renderBuilder={renderBuilder} /> )}
) : null; const fallbackAttributesQueryBuilder = route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
Fallback
{route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && ( { onChangeFallbackTeamMembersQuery( route, immutableTree, attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields ); }} renderBuilder={renderBuilder} /> )}
) : null; return ( { setRoute(route.id, { name: label }); }} deleteField={ route.isFallback ? null : { check: () => routes.length !== 1, fn: () => { const newRoutes = routes.filter((r) => r.id !== route.id); setRoutes(newRoutes); }, } }>
{formFieldsQueryBuilder}
{route.isFallback ? (
{/*
{t("send_booker_to")}
*/}